GameMediaGallery.tsx 3.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110
  1. 'use client';
  2. import { useEffect, useMemo, useState } from 'react';
  3. import Image from 'next/image';
  4. import { ChevronLeft, ChevronRight, Play } from 'lucide-react';
  5. import { youTubeId } from '@/lib/utils/youtube';
  6. type MediaItem =
  7. | { type: 'video'; embed: string; thumb: string }
  8. | { type: 'image'; src: string };
  9. type Props = {
  10. trailerUrl: string|null;
  11. images: string[];
  12. };
  13. export default function GameMediaGallery({ trailerUrl, images }: Props)
  14. {
  15. const items = useMemo<MediaItem[]>(() => {
  16. const list: MediaItem[] = [];
  17. const id = youTubeId(trailerUrl);
  18. if (id) {
  19. list.push({ type: 'video', embed: `https://www.youtube.com/embed/${id}`, thumb: `https://img.youtube.com/vi/${id}/hqdefault.jpg` });
  20. }
  21. for (const src of images) {
  22. list.push({ type: 'image', src });
  23. }
  24. return list;
  25. }, [trailerUrl, images]);
  26. const [active, setActive] = useState(0);
  27. useEffect(() => {
  28. setActive(0);
  29. }, [items.length]);
  30. if (items.length === 0) {
  31. return null;
  32. }
  33. const index = Math.min(active, items.length - 1);
  34. const current = items[index];
  35. const hasVideo = items[0]?.type === 'video';
  36. const move = (delta: number) => {
  37. setActive(prev => (prev + delta + items.length) % items.length);
  38. };
  39. return (
  40. <section className="flex flex-col gap-3">
  41. <div className="relative w-full overflow-hidden rounded-xl bg-black">
  42. <div className="relative aspect-video w-full">
  43. {current.type === 'video' ? (
  44. <iframe
  45. src={current.embed}
  46. title="게임 예고편"
  47. allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
  48. allowFullScreen
  49. className="absolute inset-0 size-full"
  50. />
  51. ) : (
  52. <Image src={current.src} alt="게임 스크린샷" fill unoptimized sizes="(max-width: 1024px) 100vw, 66vw" className="object-contain" />
  53. )}
  54. </div>
  55. {items.length > 1 && (
  56. <>
  57. <button
  58. type="button"
  59. onClick={() => move(-1)}
  60. aria-label="이전"
  61. className="absolute left-2 top-1/2 flex size-9 -translate-y-1/2 items-center justify-center rounded-full bg-black/50 text-white transition hover:bg-black/70"
  62. >
  63. <ChevronLeft className="size-5" />
  64. </button>
  65. <button
  66. type="button"
  67. onClick={() => move(1)}
  68. aria-label="다음"
  69. className="absolute right-2 top-1/2 flex size-9 -translate-y-1/2 items-center justify-center rounded-full bg-black/50 text-white transition hover:bg-black/70"
  70. >
  71. <ChevronRight className="size-5" />
  72. </button>
  73. </>
  74. )}
  75. </div>
  76. {items.length > 1 && (
  77. <div className="flex gap-2 overflow-x-auto pb-1">
  78. {items.map((it, i) => (
  79. <button
  80. type="button"
  81. key={it.type === 'video' ? it.embed : it.src}
  82. onClick={() => setActive(i)}
  83. aria-label={it.type === 'video' ? '예고편' : `스크린샷 ${i + (hasVideo ? 0 : 1)}`}
  84. aria-current={i === index}
  85. className={`relative aspect-video w-28 shrink-0 overflow-hidden rounded-md border-2 transition ${i === index ? 'border-[var(--text-link)]' : 'border-transparent opacity-60 hover:opacity-100'}`}
  86. >
  87. <Image src={it.type === 'video' ? it.thumb : it.src} alt="" fill unoptimized sizes="112px" className="object-cover" />
  88. {it.type === 'video' && (
  89. <span className="absolute inset-0 flex items-center justify-center bg-black/30">
  90. <Play className="size-5 text-white" fill="white" />
  91. </span>
  92. )}
  93. </button>
  94. ))}
  95. </div>
  96. )}
  97. </section>
  98. );
  99. }